本文为 Unity Entities 101 官方文档的中文翻译版
实体与组件
实体是 GameObject 的轻量级非托管替代方案。实体在许多方面与 GameObject 相似,可以扮演类似的角色,但存在关键差异:
- 与 GameObject 不同,实体不是托管对象,而仅仅是一个唯一的标识编号。
- 实体的组件通常是结构体值。
- 实体的组件没有与
MonoBehaviour的“事件函数”(例如OnUpdate、OnStart)等效的功能。 - 虽然实体组件类型可以包含方法,但通常不鼓励这样做。
- 单个实体对于任何给定类型只能拥有一个组件,例如单个实体不能同时拥有两个类型均为 Foo 的组件。
- 实体没有内置的父子关系概念。相反,标准的
Parent组件包含对另一个实体的引用,从而允许构建实体变换层级结构。
基本组件类型通过创建实现 IComponentData 的结构体来定义。
// an entity component type with two fields
public struct Health : IComponentData
{
public int HitPoints;
public float ArmourRating;
}
IComponentData 结构体应为非托管类型,因此不能包含任何托管字段类型。具体允许的字段类型包括:
- Blittable 类型
- bool
- char
BlobAssetReference<T>,指向 Blob 数据结构的引用Collections.FixedString,固定大小的字符缓冲区Collections.FixedList- Fixed array(仅允许在不安全上下文中使用)
- 符合这些相同限制的其他结构体类型
实体世界与实体管理器
World 是实体的集合。实体的 ID 编号仅在其所属世界内具有唯一性,即不同世界中具有相同 ID 的实体彼此完全无关。
一个世界还拥有一套系统,这些系统是在主线程上运行的代码单元,通常每帧执行一次。世界中的实体通常只能由该世界的系统及其调度的作业访问(但这并非强制限制)。
世界中的实体通过世界的 EntityManager 进行创建、销毁和修改,其方法包括:
CreateEntity():创建一个新实体。Instantiate():创建一个新实体,并复制现有实体的所有组件。DestroyEntity():销毁一个现有实体。AddComponent<T>():向现有实体添加一个类型为 T 的组件。RemoveComponent<T>():从现有实体中移除一个类型为 T 的组件。HasComponent<T>():如果实体当前拥有类型为 T 的组件,则返回 true。GetComponent<T>():获取实体类型为 T 的组件值。SetComponent<T>():覆盖实体类型为 T 的组件值。
原型
原型代表世界中组件类型的特定唯一组合:世界中所有具有特定组件类型集合的实体都存储在同一个原型中。例如:
- 所有包含组件类型 A、B 和 C 的实体都存储在同一个原型中,
- 仅包含组件类型 A 和 B(不含 C)的实体则存储在第二个原型中,
- 而所有包含组件类型 B 和 D 的实体则存储在第三个原型中。
实际上,添加或移除实体的组件会改变该实体所属的原型,这要求 EntityManager 必须将实体移动到新的原型中。
当你向实体添加或移除组件时,EntityManager 会将实体移动到对应的原型中。例如,若某实体原本拥有 X、Y、Z 三种组件类型,当你移除其 Y 组件后,EntityManager 会将该实体移至仅包含 X 和 Z 组件的原型,同时保留 X 和 Z 的数值。如果当前世界中不存在这样的原型,EntityManager 会自动创建它。
注意:频繁在原型间移动大量实体所产生的开销可能会累积到不容忽视的程度。
原型是由 EntityManager 在你创建和修改实体时自动生成的,因此无需显式创建原型。
即使某个原型中的所有实体都被移除,该原型也会持续存在直至其所属的世界被销毁。
数据块
原型中的实体存储在属于该原型的 16KiB 内存块中,这些内存块被称为数据块。每个数据块最多存储 128 个实体。(如果某个原型中每个实体所需空间超过 16KiB/128,则每个数据块的最大实体数量会相应减少)。
每个类型的实体 ID 和组件都存储在数据块内各自独立的数组中。例如,在包含组件类型 A 和 B 的实体原型中,每个数据块将存储三个数组:
- 一个数组用于存储实体 ID
- 第二个数组用于存储 A 组件
- 第三个数组用于存储 B 组件
数据块中第一个实体的 ID 和组件存储在这些数组的索引 0 位置,第二个实体存储在索引 1 位置,第三个实体存储在索引 2 位置,依此类推。
区块的数组始终保持紧密排列:
- 当新实体被添加到块中时,它会被存储在数组的第一个空闲索引位置。
- 当实体从块中移除时(无论是由于实体被销毁还是因为它被移动到另一个原型),块中的最后一个实体会被移动以填补空缺。
块的创建和销毁由 EntityManager 处理:
- 仅当实体被添加到其所有现有数据块都已满的原型时,才会创建新数据块。
- 仅当数据块中的最后一个实体被移除时才会销毁该数据块。
任何在区块内添加、移除或移动实体的 EntityManager 操作都被称为结构变更。这类变更只能在主线程中进行,无法在作业中执行(不过正如我们稍后将讨论的,可以使用 EntityCommandBuffer 作为变通方案)。
查询
EntityQuery 能够高效地查找所有具有指定组件类型集合的实体。例如,如果某个查询要查找所有具有组件类型 A 和 B 的实体,那么该查询将收集所有包含 A 和 B 的原型对应的区块,无论这些原型可能包含哪些其他组件类型。这样的查询不仅会匹配具有组件类型 A 和 B 的实体,同时也会匹配例如具有组件类型 A、B 和 C 的实体。
注意:与查询匹配的原型将被缓存,直到下次向世界添加新原型为止。由于世界中现有原型集合往往在程序生命周期早期趋于稳定,这种缓存机制通常有助于大幅降低查询开销。
查询还可以指定要从匹配原型中排除的组件类型。例如,若某查询要求"查找所有拥有组件类型 A 和 B 但不包含组件类型 C 的实体",则该查询将匹配具有 A 和 B 组件类型的实体,但不会匹配同时具有 A、B 和 C 组件类型的实体。
实体 ID
实体 ID 通过 Entity 结构体表示,该结构体包含两个整型参数:index 和 version
为了通过 ID 查找实体,世界的 EntityManager 维护着一个实体元数据数组。实体的索引表示其在该元数据数组中的槽位,该槽位存储着指向该实体所在块的指针,以及实体在块内的索引。当某个索引不存在实体时,该索引处的块指针为空。例如,当前索引 1、2 和 5 处不存在实体,因此这些槽位中的块指针均为空:

实体版本号允许在实体被销毁后重复使用实体索引:当实体被销毁时,其索引处存储的版本号会递增,因此如果某个 ID 的版本号与索引处存储的版本号不匹配,则该 ID 必然指向一个已被销毁或可能从未存在过的实体。
标记组件
没有字段的 IComponentData 结构体称为标签组件。虽然标签组件不存储任何数据,但它们仍可以像其他组件类型一样在实体上添加和移除,这对查询非常有用。例如,如果我们所有代表怪物的实体都有一个 Monster 标签组件,那么查询 Monster 组件类型将匹配所有怪物实体。
// a tag component
public struct Monster : IComponentData
{
}
动态缓冲区组件
DynamicBuffer 是一种可调整大小的数组组件类型。要定义 DynamicBuffer 组件类型,需要创建一个实现 IBufferElementData 接口的结构体。
// a dynamic buffer component type
public struct Waypoint : IBufferElementData
{
public float3 Value;
}
每个实体的缓冲区存储着长度、容量和一个指针:
Length表示缓冲区中的元素数量。它从0开始,当您向缓冲区追加值时递增。Capacity表示缓冲区的存储空间大小。它最初与内部缓冲区容量相匹配(默认为 128/sizeof(T) ,但可通过IBufferElementData结构体上的InternalBufferCapacity属性指定)。设置容量会调整缓冲区大小。- 指针指示缓冲区内容的位置。初始状态下它为 null,表示内容直接存储在区块中。如果设置的容量超过内部缓冲区容量,将在区块外分配一个更大的新数组,内容被复制到这个外部数组,指针将指向这个新数组。如果缓冲区长度超过外部数组的容量,缓冲区内容将被复制到区块外另一个更大的新数组中,旧数组会被释放。缓冲区也可以被缩小。
当 EntityManager 销毁区块本身时,内部缓冲区容量和外部容量(如果存在)将被释放。
注意:当动态缓冲区存储在区块外时,内部容量实际上被浪费了,且访问缓冲区内容需要额外追踪指针。如果确保不超过内部容量限制,就能避免这些开销。当然,在许多情况下,要维持在这个限制内可能需要过大的内部容量。另一种选择是将内部容量设为 0,这意味着任何非空缓冲区都将始终存储在区块外。这会导致访问缓冲区时始终需要追踪指针,但避免了区块中未使用空间的浪费。
EntityManager 为动态缓冲区提供了以下关键方法:
AddComponent<T>():向实体添加类型为 T 的组件,其中 T 可以是动态缓冲区组件类型。AddBuffer<T>():向实体添加类型为 T 的动态缓冲区组件;以DynamicBuffer<T>形式返回新缓冲区。RemoveComponent<T>():从实体中移除类型为 T 的组件,其中 T 可以是动态缓冲区组件类型。HasBuffer<T>():如果实体当前拥有类型为 T 的动态缓冲区组件,则返回 true。GetBuffer<T>():将实体的类型为 T 的动态缓冲区组件以DynamicBuffer<T>形式返回。
DynamicBuffer<T> 表示单个实体的类型为 T 的动态缓冲区组件。其关键属性和方法包括:
Length:获取或设置缓冲区的长度。Capacity:获取或设置缓冲区的容量。Item[Int32]:获取或设置指定索引处的元素。Add():将元素添加到缓冲区末尾,必要时会调整其大小。Insert():在指定索引处插入元素,必要时会调整大小。RemoveAt():移除指定索引处的元素。
注意:执行任何结构性变更操作都会使 DynamicBuffer “失效”,这意味着如果随后使用该 DynamicBuffer,将抛出异常。若要在结构性变更后再次使用缓冲区,必须重新获取它。
系统
系统是属于实体世界的一个代码单元,在主线程上运行(通常每帧一次)。通常,系统只会访问其所属世界的实体,但这并非强制限制。
系统被定义为实现 ISystem 接口的结构体,该接口包含三个关键方法:
OnUpdate():通常每帧调用一次(具体取决于系统所属的系统组)OnCreate():在首次调用OnUpdate前以及系统恢复运行时调用OnDestroy():在系统被销毁时调用
注意:这些方法都有默认的空实现,因此当您不需要它们时,可以在系统中省略这些方法,例如如果您将 OnCreate 方法体留空,可以直接省略整个方法。
如果系统的 Enabled 属性设置为 false,其更新将被跳过。
系统还可以额外实现 ISystemStartStop 接口,该接口包含以下方法:
OnStartRunning():在首次调用OnUpdate之前以及系统被重新启用后(即系统的Enabled属性从false更改为true时)调用。OnStopRunning():在OnDestroy之前以及系统被禁用后调用(即系统的Enabled属性从true变为false时)。
系统组与系统更新顺序
世界中的系统被组织成系统组。每个系统组都有一个有序的子项列表(系统和其他系统组),默认情况下,每个系统组按其排序顺序调用子项的 OnUpdate。实际上,系统组形成了一个决定系统更新顺序的层次结构。
- 系统组被定义为一个继承自
ComponentSystemGroup的类。 - 当系统组更新时,该组通常会按排序顺序更新其子项,但可以通过重写组的更新方法来覆盖此默认行为。例如,
FixedStepSimulationGroup具有自定义更新行为,每帧会零次或多次更新其子项,以近似固定的更新间隔。 - 每当向组中添加或移除子项时,组的子项都会重新排序。
- 通过
UpdateInGroup特性将子项添加到系统组。若未使用此特性,系统和系统组默认会添加到SimulationSystemGroup中。 UpdateBefore和UpdateAfter特性可用于确定组内子项间的相对排序顺序。例如,若 FooSystem 具有特性[UpdateBefore(typeof(BarSystem))],则 FooSystem 将在排序顺序中被置于 BarSystem 之前的某个位置。但如果 FooSystem 和 BarSystem 不属于同一系统组,此特性除了触发警告外不会产生任何效果。- 如果某个组的子项排序属性存在矛盾(例如子项 A 被标记为在子项 B 之前更新,但子项 B 同时被标记为在子项 A 之前更新),则在排序该组的子项时会抛出异常。
// A system group.
// Because this group doesn't override OnUpdate, it follows the default
// behaviour: its children will be updated in their sorted order.
public class MonsterSystemGroup : ComponentSystemGroup
{
}
// a child system of the MonsterSystemGroup
[UpdateInGroup(typeof(MonsterSystemGroup))]
public struct VampireSystem : ISystem
{
...
}
创建世界与系统
当进入运行模式时,默认的自动引导流程会创建一个包含三个系统组的默认世界:
InitializationSystemGroup:在 Unity 玩家循环的初始化阶段末尾更新。SimulationSystemGroup:在 Unity 玩家循环的Update阶段末尾更新。通常是放置游戏逻辑的地方。PresentationSystemGroup:在 Unity 玩家循环的PreLateUpdate阶段末尾更新。通常是放置渲染代码的地方。
自动引导过程会创建每个系统和系统组的实例(除了带有 DisableAutoCreation 属性的系统)。这些实例会被添加到 SimulationSystemGroup 中,除非被 UpdateInGroup 属性覆盖。例如,如果一个系统具有 [UpdateInGroup(typeof(InitializationSystemGroup))] 属性,那么该系统将被添加到 InitializationSystemGroup 中,而不是 SimulationSystemGroup 中。
可以通过脚本定义禁用自动引导过程:
#UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_RUNTIME_WORLD:禁用默认世界的自动引导。#UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP_EDITOR_WORLD:禁用编辑器世界的自动引导。#UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP:同时禁用默认世界和编辑器世界的自动引导。
当禁用自动引导时,您的代码需要负责:
- 创造世界。
- 调用
World.GetOrCreateSystem<T>()将系统和系统组实例添加到世界中。 - 在 Unity 玩家循环中注册顶级系统组(如
SimulationSystemGroup)以进行更新。
或者,您可以通过创建实现 ICustomBootstrap 接口的类来自定义引导逻辑。
世界与系统中的时间
每个世界都有一个 Time 属性,它返回一个 TimeData 结构体,其中包含帧增量时间和已用时间。该时间值由世界的 UpdateWorldTimeSystem 更新。可以通过以下 World 方法来操作时间值:
SetTime:设置时间值。PushTime:临时更改时间值。PopTime:恢复最近一次推送之前的时间值。
某些系统组(例如 FixedStepSimulationSystemGroup)会在更新其子项前推送时间值,并在完成更新后弹出该值。实际上,这些系统组向其子项呈现的是"虚假"时间值。
SystemState
系统的 OnUpdate()、OnCreate() 和 OnDestroy() 方法会接收 SystemState 参数。SystemState 代表系统实例的状态,包含以下重要方法和属性:
- 世界:该系统所属的世界。
- 实体管理器:该系统所属世界的实体管理器。
- 依赖关系:用于在系统间传递作业依赖关系的
JobHandle。 - 获取实体查询():返回一个实体查询。
GetComponentTypeHandle<T>():返回一个ComponentTypeHandle<T>。GetComponentLookup<T>():返回一个ComponentLookup<T>。
重要提示:虽然可以直接从 EntityManager 获取实体查询、组件类型句柄和组件查找器,但通常系统应仅从 SystemState 获取这些内容。通过 SystemState 获取时,系统访问的组件类型会被跟踪,这对于系统的 Dependency 属性正确传递系统间的作业依赖关系至关重要(稍后讨论)。
SystemAPI
SystemAPI 类包含许多静态便捷方法,其功能范围与 World、EntityManager 和 SystemState 类高度重合。
SystemAPI 方法依赖于源码生成器,因此仅适用于系统内部和 IJobEntity(不适用于 IJobChunk)。使用 SystemAPI 的优势在于这些方法在两种上下文中能产生相同结果,因此采用 SystemAPI 的代码通常能更轻松地在两种场景间复制粘贴。
注意:若不确定在何处查找 Entities 核心功能,通用准则是优先查阅 SystemAPI。若 SystemAPI 未提供所需功能,可查看 SystemState,若仍未找到,最后查阅 EntityManager 和 World。
SystemAPI 还提供特殊的便捷 Query() 方法,通过源码生成器可快速创建遍历匹配查询的实体与组件的 foreach 循环(如下方示例所示)。
在作业中访问实体
您可以通过 C# 作业系统将实体数据的处理工作卸载到工作线程。Entities 包提供了两个用于定义访问实体的作业接口:
IJobChunk接口,其Execute()方法会对查询匹配的每个独立块调用一次。IJobEntity接口,其Execute()方法会对查询匹配的每个实体调用一次。
虽然 IJobEntity 通常编写和使用更为便捷,但 IJobChunk 提供了更精确的控制。在大多数情况下,两者在执行等效工作时的性能表现相同。
注意:IJobEntity 实际上并非"真正的"任务类型:源代码生成会通过 IJobChunk 的实现来扩展 IJobEntity 结构体,因此实际上 IJobEntity 最终是作为 IJobChunk 被调度的。
要将 IJobChunk 或 IJobEntity 的工作分配到多个线程中执行,请通过调用 ScheduleParallel() 而非 Schedule() 来调度任务。当使用 ScheduleParallel() 时,匹配查询的块将被分配到不同的批次中,这些批次将被分配给工作线程处理。
结构性变更无法在任务内部执行,因此您应当仅在主线程中进行结构性变更。不过,任务可以通过 EntityCommandBuffer(后续将讨论)记录结构性变更命令,这些命令随后可在主线程上回放执行。
同步点
在主线程上的一些操作会触发"同步点",这些同步点会完成部分或全部当前已调度的作业。例如,调用 EntityManager.AddComponent<T>() 会首先完成所有当前已调度的访问任何 T 组件的作业。同样地,EntityQuery 方法 ToComponentDataArray<T>()、ToEntityArray() 和 ToArchetypeChunkArray() 也必须首先完成任何当前已调度的、访问与查询相同组件的作业。
在许多情况下,这些同步点还会"使"某些类型的现有实例"失效",特别是 DynamicBuffer 和 ComponentLookup<T>。当一个实例失效后,调用其方法将抛出安全检查异常。如果您仍需使用某个已失效的实例,必须获取一个新实例来替换它。
组件安全句柄
与原生集合类似,每个组件类型在每个世界中都有一个关联的作业安全句柄。这意味着,对于任何两个在同一世界中访问相同组件类型的作业,安全检查将不允许这些作业并发调度。例如,当我们尝试调度一个访问组件类型 Foo 的作业时,如果已调度的作业也访问组件类型 Foo,安全检查将抛出异常。为避免此异常:
- 必须在新作业调度前完成已调度的作业
- ...或者新作业必须依赖于已调度的作业。
注意:若两个作业对同一组件类型均具有只读访问权限,则并发调度是安全的。对于作业中从不写入的任何组件类型,请务必通过使用 ReadOnly 属性标记组件类型句柄来告知安全检查系统。
DynamicBuffer<T> 实例本身持有一个安全句柄:
- 当任何访问相同缓冲区组件类型的已调度作业仍未完成时,无法访问
DynamicBuffer<T>的内容。 - 但如果未完成的作业都仅对缓冲区组件类型具有只读访问权限,则允许主线程读取该缓冲区。
SystemState.Dependency
当我们在系统中调度一个作业时,我们希望它依赖于任何可能与新作业冲突的当前已调度作业,即使这些作业是在其他系统中调度的。为了帮助安排这些依赖关系,SystemState 有一个名为 Dependency 的 JobHandle 属性。
在系统即将更新之前:
- 系统的
Dependency属性会被完成 - ...然后
Dependency会被赋予所有其他系统中访问了与此系统相同组件类型的Dependency作业句柄的组合。例如,在一个访问了 Foo 和 Bar 组件类型的系统中,世界中所有同样访问了 Foo 或 Bar 的其他系统的Dependency将被组合成一个作业句柄,并分配给此系统的Dependency。
在每一个系统中,你都需要遵循两条规则:
- 系统更新中调度的所有任务都应(直接或间接)依赖于更新前分配给
Dependency属性的任务句柄。 - 在系统更新返回之前,应为
Dependency属性分配一个包含该次更新中所有已调度任务的句柄。
只要遵循这两条规则,系统更新中调度的每个任务都将依赖于其他系统中可能访问相同组件类型的所有已调度任务。
重要提示:系统不会追踪它们使用的原生容器,因此 Dependency 属性仅考虑组件类型,而不包括原生容器。因此,如果两个系统都调度使用相同原生容器的作业,它们的 Dependency 作业句柄不一定会包含在分配给另一个系统 Dependency 属性的作业句柄中,因此不同系统的作业将不会按预期相互依赖。在这些情况下,您可以通过在系统之间手动共享作业句柄来安排依赖关系,但通常更好的解决方案是将原生容器存储在组件中:如果您遵循这两条规则,并且两个系统都通过相同的组件类型访问该容器,那么在这两个系统中调度的作业应该会相互依赖。
ComponentLookup<T>
虽然可以通过 EntityManager 随机访问单个实体的组件,但我们通常无法在作业内部使用 EntityManager。取而代之的是,我们可以使用名为 ComponentLookup<T> 的类型,它通过实体 ID 获取和设置组件值。我们还可以使用 BufferLookup<T> 通过实体 ID 获取动态缓冲区。
重要:请记住,通过 ID 查找实体往往会产生缓存未命中的性能开销,因此通常应尽可能避免查找操作。当然,许多问题确实需要通过随机查找来解决,所以随机查找不可能被完全避免!只需注意不要随意滥用即可。
ComponentLookup<T> 和 BufferLookup<T> 的 HasComponent() 方法会在指定实体拥有 T 类型组件时返回 true。TryGetComponent<T>() 和 TryGetBuffer<T>() 方法具有相同功能,但还会在组件存在时输出组件值或缓冲区。
注意:要测试实体是否存在,我们可以调用 EntityStorageInfoLookup 的 Exists() 方法。对 EntityStorageInfoLookup 进行索引会返回 EntityStorageInfo 结构体,其中包含对实体所在块(chunk)及其在块内索引的引用。
若作业只需读取通过 ComponentLookup<T> 访问的组件,则应将 ComponentLookup<T> 字段标记为 ReadOnly 属性以通过作业安全检查。此规则同样适用于 BufferLookup<T>。
在并行调度的作业中,从 ComponentLookup<T> 获取组件值需要将字段标记为 ReadOnly 属性。安全检查不允许在并行调度的作业中通过 ComponentLookup<T> 设置组件值,因为无法保证安全性。但您可以通过标记 NativeDisableParallelForRestriction 属性来完全禁用 ComponentLookup<T> 的安全检查。BufferLookup<T> 也是如此。请确保您的代码以线程安全的方式设置组件值!
实体命令缓冲区
对实体的更改可以通过将命令记录到 EntityCommandBuffer 中来延迟执行。记录的命令会在主线程上调用 Playback() 方法时执行。
使用 EntityCommandBuffer 延迟更改在作业中特别有用,因为作业无法直接进行结构性更改(即创建实体、销毁实体、添加组件或移除组件)。作业可以通过将命令记录在 EntityCommandBuffer 中,待作业完成后在主线程上回放这些命令。EntityCommandBuffer 还能帮助我们将结构性更改延迟到帧内的几个整合点执行,而非分散在整个帧中,从而避免不必要的同步点。
EntityCommandBuffer 拥有许多(但非全部)与 EntityManager 相同的方法,包括:
CreateEntity():记录创建新实体的命令,返回临时实体 ID。DestroyEntity():记录销毁实体的命令。AddComponent<T>():记录向实体添加类型为 T 的组件的命令。RemoveComponent<T>():记录从实体移除类型为 T 的组件的命令。SetComponent<T>():记录设置类型为 T 的组件值的命令。AppendToBuffer():记录向现有缓冲区组件末尾追加单个值的命令。AddBuffer():返回一个存储在记录命令中的DynamicBuffer,当实体在回放过程中创建时,该缓冲区的内容将被复制到实体的实际缓冲区中。实际上,对返回的缓冲区进行写入操作可以设置缓冲区组件的初始内容。SetBuffer():与AddBuffer()类似,但它假设实体已经具有该组件类型的缓冲区。在回放过程中,实体已有的缓冲区内容将被返回的缓冲区内容覆盖。
注意:某些 EntityManager 方法没有对应的 EntityCommandBuffer 等效方法,因为等效方法不可行或无意义。例如,EntityCommandBuffer 没有获取组件值的方法,因为读取数据无法有效地延迟执行。
注意:EntityCommandBuffer 实例在回放后无法用于继续记录命令。如需记录更多命令,请创建新的独立 EntityCommandBuffer 实例。
每个 EntityCommandBuffer 都有一个作业安全句柄,因此当出现以下情况时,安全检查会抛出异常:
- ...在主线程上调用
EntityCommandBuffer的方法,而该EntityCommandBuffer仍被任何当前已调度的作业使用。 - ...或调度一个访问已被其他当前已调度作业使用的
EntityCommandBuffer的作业(除非新作业依赖于那些其他作业)。
重要提示:您可能会想在多个作业间共享单个 EntityCommandBuffer 实例,但强烈不建议这样做。在某些情况下它可能正常工作,但在许多情况下会出现问题。例如,在多个并行作业中使用相同的 EntityCommandBuffer.ParallelWriter 可能导致命令的回放顺序出现意外情况。相反,最佳做法几乎总是为每个作业创建并使用一个 EntityCommandBuffer。不必担心性能差异:将一组命令拆分到多个 EntityCommandBuffer 中进行记录和回放,实际上并不会比将所有命令记录到单个 EntityCommandBuffer 中更加耗费资源。
临时实体 ID
当你调用 EntityCommandBuffer 的 CreateEntity() 或 Instantiate() 方法时,在回放执行命令之前不会创建新实体,因此这些方法返回的实体 ID 是临时 ID,具有负索引号。同一 EntityCommandBuffer 的后续 AddComponent、SetComponent 和 SetBuffer 命令可以使用这些临时 ID。在回放过程中,记录命令中的任何临时 ID 都将被重新映射到实际存在的实体。
重要提示:由于临时实体 ID 在其创建的 EntityCommandBuffer 实例之外没有意义,因此临时实体 ID 应仅在同一 EntityCommandBuffer 实例的后续方法调用中使用。例如,在向不同的 EntityCommandBuffer 实例记录命令时,不要使用从另一个 EntityCommandBuffer 获取的临时 ID。
EntityCommandBuffer.ParallelWriter
为了安全地记录并行作业中的命令,我们需要使用 EntityCommandBuffer.ParallelWriter——这是一个基于底层 EntityCommandBuffer 的封装器。ParallelWriter 具备与 EntityCommandBuffer 本身大部分相同的方法,但为了确保确定性,ParallelWriter 的所有方法都需要额外传入一个“排序键”参数。
当 EntityCommandBuffer.ParallelWriter 在并行作业中记录命令时,来自不同线程的命令在缓冲区中的顺序取决于线程调度,导致顺序具有不确定性。这种非确定性会带来以下问题:
- 确定性代码通常更易于调试。
- 部分网络代码解决方案依赖确定性来确保在不同机器上产生一致的结果。
虽然命令的录制顺序无法确定,但通过一个简单技巧可以让回放顺序变得确定:
- 每个命令会记录一个"排序键"整数,该整数作为首个参数传递给每个命令方法。
- 回放方法在执行命令前会按排序键对命令进行排序。
只要排序键值与每个已录制命令之间存在确定性映射关系,这种排序机制就能确保回放顺序的确定性。
在 IJobEntity 中,我们通常希望使用的排序键是 ChunkIndexInQuery,这是每个块独有的值。由于排序是稳定的,并且单个块的所有实体都在单个线程中一起处理,因此该索引值适合作为记录命令的排序键。在 IJobChunk 中,我们可以使用 Execute 方法中等效的 unfilteredChunkIndex 参数。
多重回放
如果使用 PlaybackPolicy.MultiPlayback 选项创建 EntityCommandBuffer,其 Playback 方法可以被多次调用。否则,多次调用 Playback 将抛出异常。多重回放主要适用于需要重复生成一组实体的情况。
EntityCommandBufferSystem
EntityCommandBufferSystem 是一种系统,它提供了一种便捷的方式来延迟 EntityCommandBuffer 的执行。从 EntityCommandBufferSystem 创建的 EntityCommandBuffer 实例将在下一次 EntityCommandBufferSystem 更新时被执行并释放。
重要提示:请勿手动执行和释放由 EntityCommandBufferSystem 创建的 EntityCommandBuffer 实例,EntityCommandBufferSystem 会为您执行并释放该实例。
您很少需要自行创建任何 EntityCommandBufferSystem,因为自动引导过程会将以下五个系统放入默认世界中:
BeginInitializationEntityCommandBufferSystemEndInitializationEntityCommandBufferSystemBeginSimulationEntityCommandBufferSystemEndSimulationEntityCommandBufferSystemBeginPresentationEntityCommandBufferSystem
例如,EndSimulationEntityCommandBufferSystem 在 SimulationSystemGroup 结束时更新。
注意:帧结束时没有 "EndPresentationEntityCommandBufferSystem",但您可以改用 BeginInitializationEntityCommandBufferSystem:因为一帧的结束与下一帧的开始在逻辑上是同一时间点。
变换组件与系统
LocalTransform 是表示实体变换的主要标准组件。变换层级结构可以通过三个附加组件来构建:
Parent组件存储该实体父级的 ID。Child动态缓冲区组件存储实体的子级 ID。PreviousParent组件存储实体父级 ID 的副本。
要修改变换层级结构:
- 添加
Parent组件以将实体设为父级。 - 移除实体的
Parent组件以解除其父子关系。 - 设置实体的
Parent组件以更改其父级对象。
ParentSystem 将确保:
- 每个拥有父级的实体都会包含一个指向其父级的
PreviousParent组件。 - 每个拥有一个或多个子实体的父实体都带有一个
Child缓冲区组件,该组件会引用其所有子实体。
重要提示:虽然可以安全读取实体的 Child 缓冲区和 PreviousParent 组件,但不应直接修改它们。只能通过设置实体的 Parent 组件来修改变换层级结构。
每帧中,LocalToWorldSystem 会计算每个实体的世界空间变换(根据该实体及其祖先的 LocalTransform 组件),并将其赋值给实体的 LocalToWorld 组件。
注意:Entity.Graphics 系统会读取 LocalToWorld 组件,但不会读取其他任何变换组件,因此 LocalToWorld 是实体渲染所需的唯一变换组件。
烘焙与实体场景
烘焙是通过执行烘焙器和烘焙系统,在构建时从子场景创建实体场景的过程:
- 实体场景是一组经过序列化的实体和组件,可在运行时加载。
- 子场景是通过
SubScene MonoBehaviour嵌入到其他场景中的 Unity 场景资源。 - 烘焙器是继承
Baker<T>的类,其中 T 是MonoBehaviour。带有Baker的MonoBehaviour被称为“创作组件”。 - 烘焙系统是带有
[WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)]属性的系统。(烘焙系统通常仅在高级使用场景中需要。)
子场景的烘焙过程包含以下几个主要步骤:
- 为子场景中的每个游戏对象创建对应的实体。
- 执行子场景中每个创作组件的烘焙器。每个烘焙器可以读取创作组件,并向对应实体添加组件。
- 烘焙系统被执行。每个系统都可以读取和修改已烘焙的实体。与烘焙器不同,烘焙系统